SPB Git forge

spb/fetcha

Public
11commits 1branches 0releases
1.5 MBsize
maindefault branch
16 days agolast push
TypeScript 97.5% SQL 1.4% Python 0.8%
10.4 KB · 234 lines tsx
Raw Blame History
1import Link from "next/link";2import { notFound } from "next/navigation";3import { ArrowLeft, LifeBuoy } from "lucide-react";4import { ERROR_MESSAGES, type ErrorCode } from "@fetcha/core";5import { getWorkspace } from "@/lib/session";6import { formatBytes, formatDate, formatMs, formatUsd, timeAgo } from "@/lib/format";7import { getRequestDetail } from "@/lib/queries/dashboard";8import { PageHeader } from "@/components/ui/page-header";9import { Badge, StatusBadge } from "@/components/ui/badge";10import { Button } from "@/components/ui/button";11import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";12import { CopyButton } from "@/components/ui/copy-button";13import { Alert } from "@/components/ui/alert";14import { RequestDetailTabs } from "@/components/dashboard/requests/request-detail-tabs";15import { TimingWaterfall } from "@/components/dashboard/requests/timing-waterfall";16import { AttemptsTimeline } from "@/components/dashboard/requests/attempts-timeline";17import { HeadersTables } from "@/components/dashboard/requests/headers-table";18import { HttpCode, NetworkLabel } from "@/components/dashboard/requests/requests-table";1920export const dynamic = "force-dynamic";2122function Row({ label, children, mono }: { label: string; children: React.ReactNode; mono?: boolean }) {23  return (24    <div className="grid grid-cols-[140px_1fr] gap-3 px-4 py-2.5 text-[13px] sm:grid-cols-[180px_1fr]">25      <dt className="text-fg-subtle">{label}</dt>26      <dd className={`min-w-0 break-all ${mono ? "font-mono tabular" : ""}`}>{children}</dd>27    </div>28  );29}3031export default async function RequestDetailPage({ params }: { params: Promise<{ id: string }> }) {32  const [ws, { id }] = await Promise.all([getWorkspace(), params]);33  if (!/^req_[A-Za-z0-9]{4,64}$/.test(id)) notFound();34  const req = await getRequestDetail(ws.organization.id, id, { providerVisibility: ws.organization.providerVisibility });35  if (!req) notFound();3637  const headerCount = Object.keys(req.requestHeaders ?? {}).length + Object.keys(req.responseHeaders ?? {}).length;38  const errorHelp = req.errorCode && req.errorCode in ERROR_MESSAGES ? ERROR_MESSAGES[req.errorCode as ErrorCode] : null;39  const otherProject = req.projectId !== ws.project.id;4041  const overview = (42    <div className="grid gap-4 lg:grid-cols-2">43      <Card>44        <CardHeader className="pb-1">45          <CardTitle className="text-[14px]">Request</CardTitle>46        </CardHeader>47        <dl className="divide-y divide-border">48          <Row label="URL" mono>49            <a href={req.url} target="_blank" rel="noreferrer noopener" className="text-accent underline-offset-4 hover:underline">50              {req.url}51            </a>52          </Row>53          {req.finalUrl && req.finalUrl !== req.url ? (54            <Row label="Final URL" mono>55              {req.finalUrl}56            </Row>57          ) : null}58          <Row label="Method" mono>59            {req.method}60          </Row>61          <Row label="Domain">{req.domain}</Row>62          <Row label="Format" mono>63            {req.format}64          </Row>65          <Row label="Source">66            <Badge variant="outline">{req.source}</Badge>67            {req.browser ? (68              <Badge variant="warning" className="ml-1.5">69                browser requested70              </Badge>71            ) : null}72          </Row>73          <Row label="Project">74            {req.projectName}75            {otherProject ? <span className="ml-1.5 text-[11.5px] text-fg-subtle">(not the current project)</span> : null}76          </Row>77          <Row label="API key">78            {req.apiKey ? (79              <span className="inline-flex flex-wrap items-center gap-1.5">80                <Link href="/dashboard/api-keys" className="underline-offset-4 hover:underline">81                  {req.apiKey.name}82                </Link>83                <code className="font-mono text-[12px] text-fg-muted">84                  {req.apiKey.prefix}…{req.apiKey.last4}85                </code>86                {req.apiKey.revoked ? <Badge variant="danger">revoked</Badge> : null}87              </span>88            ) : (89              <span className="text-fg-subtle">{req.source === "playground" ? "Playground (no key)" : "—"}</span>90            )}91          </Row>92          <Row label="Session">93            {req.sessionId ? (94              <Link href="/dashboard/sessions" className="font-mono text-accent underline-offset-4 hover:underline">95                {req.sessionId}96              </Link>97            ) : (98              <span className="text-fg-subtle">none</span>99            )}100          </Row>101        </dl>102      </Card>103      <Card>104        <CardHeader className="pb-1">105          <CardTitle className="text-[14px]">Routing and result</CardTitle>106        </CardHeader>107        <dl className="divide-y divide-border">108          <Row label="Network">109            <NetworkLabel network={req.network} requested={req.requestedNetwork} />110            <span className="ml-1.5 text-[11.5px] text-fg-subtle">requested {req.requestedNetwork}</span>111          </Row>112          <Row label="Location" mono>113            {[req.country, req.region, req.city].filter(Boolean).join(" · ") || <span className="font-sans text-fg-subtle">any</span>}114          </Row>115          <Row label="HTTP status">116            <HttpCode code={req.httpStatus} />117          </Row>118          <Row label="Latency" mono>119            {formatMs(req.latencyMs)}120          </Row>121          <Row label="Attempts" mono>122            {req.attempts}123          </Row>124          <Row label="Bytes in / out" mono>125            {formatBytes(req.bytesIn)} / {formatBytes(req.bytesOut)}126          </Row>127          <Row label="Price" mono>128            {formatUsd(req.priceUsd, true)}129            {req.cached ? <Badge variant="info" className="ml-1.5 font-sans">cached</Badge> : null}130          </Row>131          {req.errorCode ? (132            <Row label="Error" mono>133              <span className="text-danger">{req.errorCode}</span>134              {req.errorMessage ? <div className="mt-0.5 font-sans text-fg-muted">{req.errorMessage}</div> : null}135            </Row>136          ) : null}137          <Row label="Completed">{req.completedAt ? formatDate(req.completedAt, { timeStyle: "medium" }) : <span className="text-fg-subtle">pending</span>}</Row>138        </dl>139      </Card>140    </div>141  );142143  return (144    <div className="flex flex-col gap-5">145      <Link href="/dashboard/requests" className="inline-flex items-center gap-1 text-[12.5px] text-fg-muted underline-offset-4 hover:text-fg hover:underline">146        <ArrowLeft className="size-3.5" /> Requests147      </Link>148      <PageHeader149        eyebrow="Request"150        title={151          <span className="inline-flex flex-wrap items-center gap-2">152            <span className="font-mono text-[18px] sm:text-[20px]">{req.id}</span>153            <CopyButton value={req.id} />154            <StatusBadge status={req.status} />155          </span>156        }157        description={158          <>159            {formatDate(req.createdAt, { timeStyle: "medium" })} ({timeAgo(req.createdAt)}) · {req.method} {req.domain}160          </>161        }162        actions={163          <Button asChild variant="outline" size="sm">164            <Link href={`/dashboard/requests?id=${encodeURIComponent(req.id)}`}>Find in log</Link>165          </Button>166        }167      />168169      {req.status === "failed" && errorHelp ? (170        <Alert variant="danger" title={req.errorCode ?? "Request failed"}>171          {req.errorMessage ?? errorHelp}172          {req.errorMessage && req.errorMessage !== errorHelp ? <span className="block text-fg-subtle">{errorHelp}</span> : null}173        </Alert>174      ) : null}175176      <div className="grid gap-5 lg:grid-cols-[1fr_280px]">177        <div className="min-w-0">178          <RequestDetailTabs179            attemptCount={req.attemptRows.length}180            headerCount={headerCount}181            overview={overview}182            attempts={183              <Card>184                <CardHeader>185                  <CardTitle className="text-[14px]">Attempts</CardTitle>186                  <CardDescription>Each route the router tried, in order. A blocked route escalates to another network or IP automatically.</CardDescription>187                </CardHeader>188                <CardContent>189                  <AttemptsTimeline attempts={req.attemptRows} providerVisibility={ws.organization.providerVisibility} />190                </CardContent>191              </Card>192            }193            headers={<HeadersTables request={req.requestHeaders} response={req.responseHeaders} />}194            timing={<TimingWaterfall timing={req.timing} totalMs={req.latencyMs} />}195          />196        </div>197        <aside className="flex flex-col gap-4">198          <Card>199            <CardHeader className="pb-2">200              <CardTitle className="flex items-center gap-2 text-[14px]">201                <LifeBuoy className="size-4 text-fg-subtle" aria-hidden /> Need help?202              </CardTitle>203            </CardHeader>204            <CardContent className="text-[13px] text-fg-muted">205              Include this request ID when contacting{" "}206              <a href={`mailto:support@fetcha.co?subject=${encodeURIComponent(`Request ${req.id}`)}`} className="text-accent underline-offset-4 hover:underline">207                support@fetcha.co208              </a>209              . It lets us trace every attempt without you sharing the URL or headers.210              <div className="mt-3 flex items-center gap-1 rounded-md border border-border bg-bg-subtle px-2 py-1 font-mono text-[12px] text-fg">211                <span className="truncate">{req.id}</span>212                <CopyButton value={req.id} className="ml-auto" />213              </div>214            </CardContent>215          </Card>216          <Card>217            <CardHeader className="pb-2">218              <CardTitle className="text-[14px]">Reading this page</CardTitle>219            </CardHeader>220            <CardContent className="space-y-2 text-[12.5px] text-fg-muted">221              <p>222                <strong className="text-fg">Attempts</strong> greater than 1 means the first route was blocked or timed out and the router escalated. You are billed for the bytes that were actually transferred.223              </p>224              <p>225                <strong className="text-fg">Network</strong> is the class that served the final attempt. <code className="font-mono">auto</code> resolves to residential today.226              </p>227            </CardContent>228          </Card>229        </aside>230      </div>231    </div>232  );233}234